iT邦幫忙

2026 iThome 鐵人賽

DAY 3
0
IT Operation

AI 輔助開發下,測試如何保住品質防線系列 第 3

Day 03:把廠商官方 SDK,包進一個多人共用的套件慣例裡

  • 分享至 

  • xImage
  •  

前言:官方 SDK 都出了,為什麼還要包一層

「廠商都出官方 SDK 了,為什麼還要多包一層 Omnipay 驅動套件,不是直接用官方 SDK 比較快?」

這是一個很實際的問題——如果你只服務自己的專案,直接用官方 SDK 確實比較快。但 omnipay-ecpay 這種驅動套件存在的理由,是讓任何已經在用 Omnipay 的專案,只要換一個 Gateway 名稱,就能無痛接上綠界,不用重新學一套 API。今天要看的是,這件事具體是怎麼做到的。

今日目標

  • 看懂 HasECPay 這個 Trait 怎麼包裝綠界官方 SDK 的 Factory
  • 知道套件裡哪些地方直接用了官方 SDK 提供的服務類別/例外類別
  • 理解「廠商 SDK 的介面」跟「Omnipay 統一介面」是兩層不同的抽象,驅動套件的價值就是接住這個落差
  • 對照一組「不包裝、直接裸用官方 SDK」的反例,看差別在哪

HasECPay:一個只有 15 行,卻被多個類別共用的 Trait

trait HasECPay
{
    private $globalBackup = [];

    protected function factory($request, $class)
    {
        $factory = new Factory([
            'hashKey' => $request->getHashKey(),
            'hashIv' => $request->getHashIV(),
        ]);

        return $factory->create($class);
    }
}

這裡的 Factory 是綠界官方 ecpay/sdk 套件提供的 Ecpay\Sdk\Factories\Factory,負責依名稱建立官方 SDK 裡的各種服務類別(例如驗簽、發送 HTTP 請求的服務)。HasECPay 把「怎麼建立官方服務物件」這件事收斂成一個 factory() 方法,讓需要呼叫綠界 API 的 Request 類別(FetchTransactionRequestRefundRequestVoidRequest)都能重複使用,不用每個類別各自寫一次 new Factory(...)

FetchTransactionRequest 用它來查詢交易:

protected function getTradeInfo($data)
{
    return $this->factory($this, 'PostWithCmvVerifiedEncodedStrResponseService')
        ->post($data, $this->getEndpoint());
}

RefundRequest 用它來送出退款動作:

protected function doAction($data)
{
    return $this->factory($this, 'PostWithCmvEncodedStrResponseService')
        ->post($data, 'https://payment.ecpay.com.tw/CreditDetail/DoAction');
}

這兩個服務名稱看起來只差一個「Verified」,很容易被誤讀成「一個在送出前驗證、一個在送出後驗證」。實際去讀官方 SDK 的 Factory::create() 原始碼才發現:兩者都會幫送出的請求算 CheckMacValue(都用同一個 CheckMacValueRequest),差別只在回應要不要驗簽——PostWithCmvVerifiedEncodedStrResponseService 多包了一層 VerifiedEncodedStrResponse,會在解析回應資料後呼叫 CheckMacValueService::verify(),驗簽失敗就丟出 RtnException(106)(訊息是 CheckMacValue verify failed);PostWithCmvEncodedStrResponseService 用的是不做驗證的 EncodedStrResponse,單純把回應字串解析成陣列就結束。FetchTransactionRequest 查詢交易時綠界會回傳完整交易資料,值得驗簽;RefundRequest 送出的是一個動作指令,回應相對單純,這大概是為什麼兩者選用不同服務的原因(這點是從程式碼行為推論,不是官方文件明講的設計理由)。

官方 SDK 出現的地方,不只有 Factory

翻過 src/Message/ 底下的檔案,官方 SDK 至少在四個地方被直接使用:

  • HasECPayEcpay\Sdk\Factories\Factory(建立服務物件)
  • RefundRequestFetchTransactionRequestEcpay\Sdk\Exceptions\RtnException——這是官方 SDK 裡通用的錯誤例外,不是「退款/查詢失敗」專屬,讀過 SDK 原始碼後確認它總共在 8 種情境下被拋出:CURL 連線失敗、AES 加解密失敗、CheckMacValue 產生/驗證失敗等,涵蓋的是 SDK 內部運作失敗,不是綠界業務邏輯上的失敗(例如餘額不足這類業務失敗,是透過回應裡的 RtnCode/RtnMsg 欄位表達,不會讓 SDK 丟例外)
  • PurchaseRequestEcpay\Sdk\Services\UrlService::ecpayUrlEncode()——讀過原始碼後確認具體做法是先 urlencode()、轉小寫,再把幾個特定的百分號跳脫字元(%2d/%5f/%2e/%21/%2a/%28/%29)換回對應的字面字元(-/_/./!/*/(/)),刻意對齊 .NET 的 URL 編碼慣例——這正是「廠商規格不是通用標準,套件要照抄」的一個具體例子
  • CompletePurchaseRequestEcpay\Sdk\Response\VerifiedArrayResponse(官方提供、已經驗證過簽章的回應物件)

這代表這個套件不是重新造輪子去重寫簽章驗證、URL 編碼這些邏輯,而是信任官方 SDK 處理這些細節,自己只負責把官方 SDK 的呼叫方式,轉譯成 Omnipay 期待的 Gateway/RequestInterface/ResponseInterface 樣子

真正的「改造」:把官方 SDK 的例外,轉譯成 Omnipay 的例外

包裝官方 SDK 不是只有「呼叫它」,更關鍵的一步是把它丟出來的例外,翻譯成呼叫端看得懂、預期得到的型別CompletePurchaseRequest 驗證背景通知簽章的地方就是一個具體例子:

private function checkMacValue($data)
{
    try {
        $this->factory($this, VerifiedArrayResponse::class)->get($data);
    } catch (Exception $e) {
        throw new InvalidRequestException($e->getMessage(), $e->getCode(), $e);
    }

    return $data;
}

$this->factory(...) 透過官方 Factory 建立 VerifiedArrayResponse 這個官方服務物件,呼叫它的 get($data) 做簽章驗證——驗證失敗時,官方 SDK 丟出的是它自己定義的例外類型。但這段程式碼用一個寬鬆的 catch (Exception $e) 全部接住,重新包成 Omnipay 生態系認得的 Omnipay\Common\Exception\InvalidRequestException 再丟出去(保留原始例外訊息跟前一個例外物件 $e,方便追蹤根因)。

這就是為什麼昨天 Day 04 提到的測試 testInvalidCheckMacValue,斷言抓到的是 InvalidRequestException,而不是綠界官方 SDK 自己的例外類型——呼叫這個驅動套件的人,永遠只需要認得 Omnipay 的例外體系,不需要知道底層在用哪家廠商的 SDK,更不需要知道那家 SDK 定義了什麼例外類型。這才是「包裝」真正在做的事:不只是少寫幾行 new Factory(...),而是把兩套完全不相干的錯誤處理慣例接成一套。

❌ vs ✅:如果每個 Request 類別都自己 new 官方服務

❌ 反例:每個類別各自建立官方 SDK 服務,規則散落各處
class RefundRequest extends AbstractRequest
{
    protected function doAction($data)
    {
        $factory = new Factory([
            'hashKey' => $this->getHashKey(),
            'hashIv' => $this->getHashIV(),
        ]);
        $service = $factory->create('PostWithCmvEncodedStrResponseService');

        return $service->post($data, 'https://payment.ecpay.com.tw/CreditDetail/DoAction');
    }
}

class FetchTransactionRequest extends AbstractRequest
{
    protected function getTradeInfo($data)
    {
        // 同樣的 Factory 建立邏輯,再寫一次
        $factory = new Factory([
            'hashKey' => $this->getHashKey(),
            'hashIv' => $this->getHashIV(),
        ]);
        $service = $factory->create('PostWithCmvVerifiedEncodedStrResponseService');

        return $service->post($data, $this->getEndpoint());
    }
}
✅ 正例:建立官方服務物件的規則收斂到一個 Trait
trait HasECPay
{
    protected function factory($request, $class)
    {
        $factory = new Factory([
            'hashKey' => $request->getHashKey(),
            'hashIv' => $request->getHashIV(),
        ]);

        return $factory->create($class);
    }
}

反例不是寫不出來,問題是:如果官方 SDK 哪天改了 Factory 的建構參數(例如多要求一個設定值),反例要改三、四個地方;正例只要改 HasECPay 這一處。驅動套件的價值之一,就是把「怎麼跟官方 SDK 打交道」的規則收斂到一個地方,讓上層的 Request 類別只需要關心「這次要打哪支 API、帶什麼參數」。

兩層抽象,兩種讀者

值得注意的是,這個套件同時服務兩種讀者:

  • 只想串接綠界的開發者,他們看到的是 Omnipay 統一介面($gateway->purchase(...)->send()),完全不需要知道底層在跟 Ecpay\Sdk\Factories\Factory打交道
  • 維護這個驅動套件本身的人(例如我),要同時讀懂官方 SDK 的介面設計跟 Omnipay 的介面設計,才知道怎麼把兩者接起來

這也是為什麼這種「介面轉譯層」的程式碼,通常比純業務邏輯更需要測試保護——它的正確性同時綁在兩份外部契約上(官方 SDK 的行為、Omnipay 的介面約定),任何一邊改版都可能讓轉譯出錯,卻不會馬上被兩邊任何一方的測試套件抓到,只有這個驅動套件自己的測試才擋得住。

今日思考題

如果你的專案也在包裝一個第三方 SDK,你有沒有把「建立/設定 SDK 物件」的邏輯收斂到一個地方,還是散落在每個呼叫它的地方各寫一次?下次官方 SDK 改版時,你會需要改幾個檔案?

今日重點回顧

  • HasECPay trait 用官方 Factory 建立官方 SDK 服務物件,被多個 Request 類別共用
  • 套件至少在四個地方直接使用官方 SDK 提供的類別(FactoryRtnExceptionUrlServiceVerifiedArrayResponse
  • 驅動套件不重寫簽章驗證等邏輯,而是信任官方 SDK,自己只負責介面轉譯
  • 這種轉譯層程式碼綁在兩份外部契約上,格外需要測試保護

明日預告

明天回到 README 這個老問題:如果連套件維護者自己都沒把功能寫進 README,新使用者要怎麼知道這個套件實際支援哪些付款方式?測試案例能不能真的補上這個缺口?


上一篇
Day 02:12 個付款方式類別,怎麼用 14 個 Trait 組出來
下一篇
Day 04:文件停在骨架範本,程式碼卻已經走了三年
系列文
AI 輔助開發下,測試如何保住品質防線5
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言